-
Notifications
You must be signed in to change notification settings - Fork 157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix maximum update depth exceeded #387
base: master
Are you sure you want to change the base?
Fix maximum update depth exceeded #387
Conversation
src/Spreadsheet.tsx
Outdated
// Call on change only if the data change internal | ||
if (state.model.data !== props.data) { | ||
if (!deepArrayComparison(state.model.data, props.data)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm sorry but although this is a working solution for small datasets, for big ones it could be very problematic. The fix should be to make sure the reference isn't different in the first place.
Hello Iddan, Thank you for taking the time to review my pull request. I understand your concerns regarding the potential slowdown for very large datasets. However, in my humble opinion, some slowdowns are preferable than infinite rendering loop. The main problem is that, in the original code, when the two object references are compared (either implicitly in the array of dependencies of the useEffect hook or explicitly within the useEffect's callback body) an endless loop can occur after any change. This is because there is no guarantee the object references will remain the same between renderings. To address both issues, I have introduced a new update that adds a 'lastUpdateDate' property to the state object. The concept is to keep track of the last time the data within state.model has changed. Thank you for your attention and consideration. |
@@ -132,6 +133,7 @@ export default function reducer( | |||
...state, | |||
model: updateCellValue(state.model, active, cellData), | |||
lastChanged: active, | |||
lastUpdateDate: new Date(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi Iddan,
First off, it's commonly advised against including objects directly in the dependency array of a useEffect
. This is because objects are compared by reference, not by value. What this means is that even if the content of the object hasn't changed, if a new object is created on each render, the effect will still run on every render because the references are different.
So, although comparing object references directly might seem like a good idea, it's actually not recommended.
In the case of react-spreadsheet, the code below can lead to an infinite loop due to shallow comparisons made within the useEffect body and the dependency array.
<Spreadsheet
data={data}
onChange={(newData: any) => {
console.log(newData);
setData(newData);
}}
/>
React.useEffect(() => {
if (state.model.data !== prevDataRef.current) {
// Call onChange only if the data changes internally
if (state.model.data !== props.data) {
onChange(state.model.data);
}
}
prevDataRef.current = state.model.data;
}, [state.model.data, onChange, props.data]);
To illustrate this point further, you can try changing the second block to:
React.useEffect(() => {
console.log("Use Effect called");
}, [state.model.data]);
You'll notice that even though the object might appear to be the same instance, its reference changes after every rendering. This causes the console log to be called every time there is a modification.
To summarize:
state.model.data
changes its reference every timesetCellData
is called.- Since the reference changes, the
useEffect
body is executed. - The first
if
condition is verified as the reference is always different from the one previously stored. - The second
if
condition is verified because there is no guaranteeprops.data
has a reference equal to the newstate.model.data
reference. - The
onChange
function calls thesetData
function within theSpreadSheet
object inApp.tsx
. - The loop restarts without an end.
Solution
Adding a new lastUpdateDate
property addresses this issue while avoiding resource-intensive deep nested comparisons.
With this addition, we track the last time setCellData
is executed. If the data displayed in the UI has a different lastUpdateDate
value, the useEffect
is called, and the displayed information is updated accordingly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
React.useEffect(() => {
}, [JSON.stringify(state.model.data)]);
Why not use just a string compare? For the huge datasets, it might be too slow though, but there is other libraries that can handle stringifying of an object more performant. Comparing a string, instead of having to traverse a huge object-graph is way easier.
The lastUpdate date check can probably cause some bad sideeffects as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @EdoardoGruppi for the detailed writing. I am unaware that any official React resources do not recommend object reference comparison. The real problem is the array reference changes which I don't see why it should happen more than once per mutation to the data.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@EdoardoGruppi are you using the latest version of the code? The controlled storybook story works correctly and it uses the same code as you posted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@thsorens in the same spirit of React, I try to avoid unsimple comparisons on data.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @iddan,
thanks for your responses.
I confirm that I am using the latter version of the code. To be 100% sure, I re-did the test by cloning the repo from scratch, but nothing changed. You can find a sandbox at this link to check the results I am getting. To see the logs, you just need to open the console in the embedded CodeSandbox browser.
You're correct that React doesn't explicitly advise against using non-primitive values inside the dependency array. However, many developers recommend avoiding this practice because it can lead to mistakes and unintended re-renders.
I am confident that this is the exact problem with the react-spreadsheet library. In one of my initial commits, I used a function to deep compare objects, which solved the issue. The same error can be highlighted when adding a log inside the outer if statement. The comparison state.model.data !== prevDataRef.current
returns true endlessly every time a cell changes its value.
React.useEffect(() => {
if (state.model.data !== prevDataRef.current) {
console.log('state.model.data is not equal to prevDataRef.current')
}
prevDataRef.current = state.model.data;
}, [state.model.data]);
It's worth mentioning that I later replaced that function to avoid slowdowns with a comparison made on a new lastUpdatedDate property of the state object. I think that using the date is a nice way to solve the issue since: it doesn't add much code or memory occupation, makes the comparison very fast, and provides additional information that may be useful for future features.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@iddan : I also found a different weird behavior in the storybook version. Add some random text in a cell. Put the focus on another cell. Then go back to the cell, type 1 (the number 1). The focus will automatically disappear, so it will not be possible to continue writing in the cell. It might be related to the grid running in storybook, since i cant reproduce it in the "demo" version. (Running chrome)
The fact that you are comparing 2 non-primitives in the This thing needs to be torn apart and re-architected from the ground up. If you want to make comparisons like that, it needs to be on lower-level components like the cells themselves (if possible) OR find a way to compare the equality of two primitives. While @EdoardoGruppi's solution isn't perfect, it's far better than what's happening today. |
Hi,
I encountered an issue in my project similar to that described in this open issue link.
When using the code snippet below:
Every time I made a change in a cell, I noticed that console.log was being called infinitely.
Upon investigation, I found that the issue lies within the Spreadsheet.tsx file, specifically in the following useEffect:
The problem arises from the comparisons made inside the useEffect's callback function and within the dependency array. Since we are comparing two non-primitive variables, these inequality comparisons always return true. Consequently, when the value of a cell changes, state.model.data varies, triggering the useEffect. However, the two if conditions always evaluate to true, leading to the invocation of onChange. As onChange sets new props, the useEffect is triggered again, resulting in an infinite loop.
To address this issue, I created a new function in the util.ts file that enables deep comparison of arrays. By using this function within the useEffect body, I ensure that the infinite loop is halted, and onChange is called only once.